# -*- coding: utf-8 -*-
from django.db import models, transaction
from django.contrib.auth.models import User
from datetime import datetime, timedelta
import re

from mockable import mockable
from stream import feeds, utils
import morphology

try:
    import json
except:
    import simplejson as json

class UserProfile(models.Model):
    """
    Application-specific data for User.
    """
    DEFAULT_DISPLAY_NAME = 'Unknown'

    user = models.ForeignKey(User, unique = True)
    display_name = models.CharField(max_length = 200, default = DEFAULT_DISPLAY_NAME, blank = True)
    birth_date = models.DateField(null = True, blank = True)

    def save(self, force_insert = False, force_update = False):
        if self.display_name == '' or self.display_name is None:
            self.display_name = self.DEFAULT_DISPLAY_NAME
        super(UserProfile, self).save(force_insert, force_update)

    def get_absolute_url(self):
        return "/users/%s/" % unicode(self.user.id)

def update_user_profile(sender, instance, created, **kwargs):
    if sender == User and created:
        UserProfile.objects.create(user = instance)

models.signals.post_save.connect(update_user_profile)

class DisplaySettings(models.Model):
    """
    Client display settings for user.
    """
    DISPLAY_TYPES = (
        (u'show', u'show'),
        (u'hide', u'hide')
    )

    user = models.ForeignKey(User, unique = True)
    display_tags = models.CharField(max_length = 10, choices = DISPLAY_TYPES, default = u'show')
    display_subscriptions = models.CharField(max_length = 10, choices = DISPLAY_TYPES, default = u'show')

def getOrCreateOpenIDUser(openid_url):
    from openid.consumer.consumer import SUCCESS
    from django.contrib.auth import authenticate
    endpoint = utils.PropertyBag(getDisplayIdentifier = lambda: openid_url)
    openid_response = utils.PropertyBag(identity_url = openid_url, status = SUCCESS, endpoint = endpoint)
    user = authenticate(openid_response=openid_response)
    return user

def autoTruncate(cls):
    '''
    Automatically truncate data that does not fit in a CharField.
    '''
    oldsave = cls.save
    replace_str = '...'
    def newsave(self, **kwargs):
        for f in self._meta._fields():
            if isinstance(f, models.CharField):
                val = getattr(self, f.name)
                if val is not None and len(val) > f.max_length:
                    val = val[:f.max_length - len(replace_str)] + replace_str
                    setattr(self, f.name, val)
        oldsave(self, **kwargs)
    cls.save = newsave

class Tag(models.Model):
    name = models.CharField(max_length = 200, unique = True, verbose_name = 'Tag', primary_key = True)

    def __str__(self):
        return self.name

def retrieveNamedObjects(model, field_name, names):
    """
    Retrieves objects of 'model' by 'field_name' field value and adds those that are not present there.
    """
    query_argument = { field_name + '__in' : names }
    existing_objects = model.objects.filter(**query_argument)
    existing_object_names = map(lambda x: getattr(x, field_name), existing_objects)
    result = []
    for name in names:
        if not name in existing_object_names:
            new_object = model(**{ field_name : name })
            new_object.save()
            result.append(new_object)
    result.extend(existing_objects)
    return result

def retrieveTags(tag_names):
    return retrieveNamedObjects(Tag, 'name')

class Channel(models.Model):
    RELATED_ENTRIES_ATTR = 'entries' # Name of the attribute that relates to entries belonging to this channel
    LAST_UPDATED_ATTR = 'last_updated' # Name of the attribute that stores time of last update. Channel classes without this attribute are not expected to be updatable
    DEFAULT_ENTRY_LOAD = 10 # Number of entries to load into a newly created subscription by default

    def createSubscription(self, user, **kwargs):
        ch = self
        if not self.__class__ == Channel: # Need to get the correct pk when filtering records
            ch = self.channel_ptr

        subscription, created = Subscription.objects.get_or_create(channel = ch, user = user)
        if not created:
            return (subscription, False)
        if 'name' in kwargs:
            subscription.name = kwargs['name']
        else:
            subscription.name = self.generateSubscriptionName()
        if 'import_tags' in kwargs:
            subscription.import_tags = kwargs['import_tags']
        if 'published' in kwargs:
            subscription.published = kwargs['published']
        if 'tags' in kwargs:
            subscription.tags = kwargs['tags']
        subscription.save()
        entries = self.entriesForSubscription()
        for entry in entries:
            subscription.createMessage(entry)
        return (subscription, True)

    def entriesForSubscription(self):
        """
        Entries that should be added into the newly generated subscription.
        """
        return getattr(self, self.RELATED_ENTRIES_ATTR).order_by('-updated')[:self.DEFAULT_ENTRY_LOAD]

@mockable
def now():
    from datetime import datetime
    return datetime.utcnow()

class ExternalChannelBase(Channel):
    last_updated = models.DateTimeField(null = True)

    @classmethod
    def get_or_create(cls, **kwargs):
        try:
            return cls.objects.get(author = kwargs['author'])
        except cls.DoesNotExist:
            try:
                channel = cls(**kwargs)
                external_data = channel.retrieveExternalData()
                # If everything has been OK, we can now save the channel
                channel.save()
                channel.updateFromExternalData(external_data)
                return channel
            except:
                raise cls.NotFoundError(**kwargs)

    def update(self):
        external_data = self.retrieveExternalData()
        self.updateFromExternalData(external_data)

    def updateFromExternalData(self, external_data):
        from datetime import datetime
        for external_entry in external_data:
            entry, created = self.createOrUpdateEntry(external_entry)
            if created:
                for subscription in self.subscription_set.all():
                    subscription.createMessage(entry)

        self.last_updated = now()
        self.save()

    class Meta:
        abstract = True

    class ResourceError(Exception):
        def __str__(self):
            return self.getMessage()

class FeedChannel(ExternalChannelBase):
    link = models.URLField(verify_exists = True, primary_key = True) # Where to download the feed, not to the site as in spec
    title = models.CharField(max_length = 200)

    UPDATE_PERIOD = timedelta(minutes = 30)

    DEFAULT_DATE = datetime(2000, 01, 01, 0, 0, 0) # for entries that don't have creation time

    def generateSubscriptionName(self):
        return self.title

    @staticmethod
    def get_or_create(url, **kwargs):
        feed_url = feeds.find_feed(url)
        if feed_url is None:
            raise FeedChannel.NotFoundError(url)
        try:
           feed_channel = FeedChannel.objects.get(link = feed_url)
        except FeedChannel.DoesNotExist:
            feed_external = feeds.retrieve_feed(feed_url)
            if feed_external.bozo:
                raise FeedChannel.NotFoundError(feed_url)
            feed_channel = FeedChannel.objects.create(title = feed_external.feed.title, link = feed_url, last_updated = now())
            feed_channel.updateFromExternalData(feed_external.entries)
        return feed_channel

    def retrieveExternalData(self):
        from stream import feeds
        if self.last_updated is not None:
            data = feeds.retrieve_feed(self.link, modified = self.last_updated.timetuple())
        else:
            data = feeds.retrieve_feed(self.link)
        if data.bozo:
            raise FeedChannel.ParsingError(self.link, data.bozo_exception)
        return data.entries

    def createOrUpdateEntry(self, external_entry):
        from stream import feeds
        """
        Returns two values: the entry created or updated and boolean: true if the entry was created, false otherwise.
        """
        if hasattr(external_entry, 'id'):
            entry_guid = external_entry.id
        else:
            entry_guid = None
        entry_link = external_entry.link
        if hasattr(external_entry, 'title'):
            entry_title = external_entry.title
        else:
            entry_title = None
        if hasattr(external_entry, 'tags'):
            entry_tags = map(lambda x: Tag.objects.get_or_create(name = x.term.replace(' ', '_'))[0], external_entry.tags)
        else:
            entry_tags = None
        if entry_guid is not None:
            entry_list = FeedEntry.objects.filter(guid = entry_guid)
        else:
            entry_list = FeedEntry.objects.filter(link = entry_link)
        if hasattr(external_entry, 'updated_parsed'):
            entry_updated = feeds.convertDate(external_entry.updated_parsed)
        else:
            entry_updated = None
        if entry_list:
            entry = entry_list[0]
            entry.description = external_entry.description
            entry.title = entry_title
            entry.link = entry_link
            entry.updated = entry_updated
            entry.tags.clear()
            entry.save()
            created = False
        else:
            created_time = FeedChannel.DEFAULT_DATE
            if hasattr(external_entry, 'created_parsed'):
                created_time = feeds.convertDate(external_entry.created_parsed)
            elif hasattr(external_entry, 'published_parsed'):
                created_time = feeds.convertDate(external_entry.published_parsed)
            elif entry_updated is not None:
                created_time = entry_updated
            entry = FeedEntry.objects.create(description = external_entry.description, title = entry_title, link = entry_link, guid = entry_guid, channel = self, updated = entry_updated, created = created_time)
            created = True
        if entry_tags is not None:
            for tag in entry_tags:
                entry.tags.add(tag)
            entry.save()
        return (entry, created)

    class NotFoundError(ExternalChannelBase.ResourceError):
        def __init__(self, url):
            self.url = url

        def getMessage(self):
            return 'Feed was not found at ' + self.url

    class ParsingError(ExternalChannelBase.ResourceError):
        def __init__(self, url, message):
            self.url = url
            self.message = message

        def __str__(self):
            return 'Error parsing feed "%s", message: "%s"' % (self.url, self.message)

autoTruncate(FeedChannel)


class UserChannel(Channel):
    """
    Subscription to other user's stream.
    """
    author = models.ForeignKey(User)
    tags = models.ManyToManyField(Tag)

    def generateSubscriptionName(self):
        """
        Override.
        """
        return str(self.author.id) + ' / ' + ' '.join(map(lambda x: x.name, self.tags.all()))

    def entriesForSubscription(self):
        """
        Override.
        """
        messages = Message.objects.filter(user = self.author, published = True)
        for tag in self.tags.all():
            messages = messages.filter(tags = tag)
        return map(lambda x: x.entry, messages)

    def updateSubscriptions(self, message):
        for subscription in self.subscription_set.all():
            subscription.createMessage(message.entry)

class DeliciousChannel(ExternalChannelBase):
    """
    Subscription to a delicious.com user feed.
    """
    author = models.CharField(max_length = 30, primary_key = True) # Delicious user to whose feed we subscribe

    UPDATE_PERIOD = timedelta(minutes = 30)

    def generateSubscriptionName(self):
        return 'Delicious of ' + self.author

    def retrieveExternalData(self):
        data = DeliciousChannel.retrieveUrl('http://feeds.delicious.com/v2/json/' + self.author + '?count=' + unicode(self.DEFAULT_ENTRY_LOAD))
        result = json.loads(data)
        return result

    def createOrUpdateEntry(self, external_entry):
        """
        Returns two values: the entry created or updated and boolean: true if the entry was created, false otherwise.
        """
        entry, created = DeliciousEntry.objects.get_or_create(link = external_entry['u'], channel = self, created = feeds.parseDateIso8601(external_entry['dt']))
        entry.title = external_entry['d']
        if not created:
            entry.tags.clear()
        for tag_name in external_entry['t']:
            entry.tags.add(Tag.objects.get_or_create(name = tag_name)[0])
        entry.save()
        return (entry, created)

    def entriesForSubscription(self):
        """
        Entries that should be added into the newly generated subscription.
        """
        return getattr(self, self.RELATED_ENTRIES_ATTR).all()[:self.DEFAULT_ENTRY_LOAD]

    @staticmethod
    @mockable
    def retrieveUrl(url):
        import urllib2
        retrieved_data = urllib2.urlopen(url)
        return retrieved_data.read()

    class NotFoundError(ExternalChannelBase.ResourceError):
        def __init__(self, author, **kwargs):
            self.author = author

        def getMessage(self):
            return 'Delicious user with name %s does not exist' % self.author

class TwitterChannel(ExternalChannelBase):
    """
    Subscription to a twitter.com feed.
    """
    author = models.CharField(max_length = 30) # Twitter user to whose feed we subscribe

    UPDATE_PERIOD = timedelta(minutes = 65)
    UPDATE_LIMIT = 150

    tag_regexp = re.compile(r'(@|#)([^\W\d](\w*))', re.U)

    def generateSubscriptionName(self):
        return 'Twitter statuses of ' + self.author

    def retrieveExternalData(self):
        data = TwitterChannel.retrieveUrl('http://twitter.com/statuses/user_timeline.json?screen_name=' + self.author + '&count=' + unicode(self.DEFAULT_ENTRY_LOAD))
        return json.loads(data)

    def createOrUpdateEntry(self, external_entry):
        """
        Returns two values: the entry created or updated and boolean: true if the entry was created, false otherwise.
        """
        try:
            id = str(external_entry['id'])
            entry = TwitterEntry.objects.get(twitter_id = id)
            return (entry, False)
        except TwitterEntry.DoesNotExist:
            text = external_entry['text']
            entry = TwitterEntry.objects.create(twitter_id = id, description = text, channel = self, created = feeds.parseDateRfc822(external_entry['created_at']))
            tag_text = self.tag_regexp.findall(text)
            entry.tags.clear()
            for tag_name in tag_text:
                entry.tags.add(Tag.objects.get_or_create(name = tag_name[1])[0])
        entry.save()
        return (entry, True)

    def entriesForSubscription(self):
        """
        Entries that should be added into the newly generated subscription.
        """
        return getattr(self, self.RELATED_ENTRIES_ATTR).all()[:self.DEFAULT_ENTRY_LOAD]

    @staticmethod
    @mockable
    def retrieveUrl(url):
        import urllib2
        retrieved_data = urllib2.urlopen(url)
        return retrieved_data.read()

    class NotFoundError(ExternalChannelBase.ResourceError):
        def __init__(self, author, **kwargs):
            self.author = author

        def getMessage(self):
            return 'Twitter user with name %s does not exist' % self.author

class EntryBase(models.Model):
    # Creation time: for imported entries - on external service, for local entries - on Streaminatr.
    created = models.DateTimeField()
    entry_type = models.IntegerField()

    ENTRY_TYPES = {
        'delicious_entry' : 1,
        'twitter_entry' : 2,
        'bookmark_entry' : 3,
        'feed_entry' : 4
    }

    def save(self, force_insert = False, force_update = False):
        if self.entry_type is None:
            self.entry_type = self.class_entry_type
        super(EntryBase, self).save(force_insert, force_update)

    def promote(self):
        if self.__class__ != EntryBase:
            return self
        if self.entry_type == self.ENTRY_TYPES['delicious_entry']:
            return self.deliciousentry
        elif self.entry_type == self.ENTRY_TYPES['twitter_entry']:
            return self.twitterentry
        elif self.entry_type == self.ENTRY_TYPES['bookmark_entry']:
            return self.bookmarkentry
        elif self.entry_type == self.ENTRY_TYPES['feed_entry']:
            return self.feedentry
        else:
            return None

    def getBases(self):
        words = []
        if hasattr(self, 'title') and self.title is not None:
            words.extend(morphology.getTextBases(self.title))
        if hasattr(self, 'description'):
            words.extend(morphology.getTextBases(self.description))
        return words

class DeliciousEntry(EntryBase):
    link = models.URLField(verbose_name = 'Link')
    title = models.CharField(max_length = 200)
    tags = models.ManyToManyField(Tag)
    channel = models.ForeignKey(DeliciousChannel, related_name = Channel.RELATED_ENTRIES_ATTR)
    class_entry_type = EntryBase.ENTRY_TYPES['delicious_entry']

    def __str__(self):
        return self.link

    class Meta:
        unique_together = ('link', 'channel')

autoTruncate(DeliciousEntry)

class TwitterEntry(EntryBase):
    description = models.TextField()
    twitter_id = models.CharField(max_length = 20, unique = True)
    tags = models.ManyToManyField(Tag)
    channel = models.ForeignKey(TwitterChannel, related_name = Channel.RELATED_ENTRIES_ATTR)
    class_entry_type = EntryBase.ENTRY_TYPES['twitter_entry']

    def getLink(self):
        return 'http://twitter.com/%s/statuses/%s' % (self.channel.author, self.twitter_id)

    link = property(getLink)

    def __str__(self):
        return self.link

class BookmarkEntry(EntryBase):
    link = models.URLField(verbose_name = 'Link', unique = True) # In fact would not mind to putting any char stuff here
    class_entry_type = EntryBase.ENTRY_TYPES['bookmark_entry']

    def __str__(self):
        return self.link

    def save(self, force_insert = False, force_update = False):
        if self.created is None:
            self.created = now()
        super(BookmarkEntry, self).save(force_insert, force_update)

class FeedEntry(EntryBase):
    # Some bad people do not set guid/id. In this case will distinguish entries by their link.
    # Link isn't a pk because other bad people just set a link to their site in all the entries.
    title = models.CharField(max_length = 200, verbose_name = 'Title', null = True)
    description = models.TextField(verbose_name = 'Description')
    updated = models.DateTimeField(verbose_name = 'Published', null = True)
    link = models.URLField(verbose_name = 'Link')
    guid = models.CharField(max_length = 200, unique = True, null = True)
    tags = models.ManyToManyField(Tag)
    channel = models.ForeignKey(FeedChannel, related_name = Channel.RELATED_ENTRIES_ATTR)
    class_entry_type = EntryBase.ENTRY_TYPES['feed_entry']

    def save(self, force_insert = False, force_update = False):
        if self.created is None:
            self.created = self.updated
        super(FeedEntry, self).save(force_insert, force_update)

    def __str__(self):
        return self.title

autoTruncate(FeedEntry)

class Subscription(models.Model):
    channel = models.ForeignKey(Channel)
    user = models.ForeignKey(User)
    name = models.CharField(max_length = 200) #User-given name, may be edited.
    tags = models.ManyToManyField(Tag)
    import_tags = models.BooleanField(default = True, verbose_name = 'Import existing tags')
    published = models.BooleanField(default = False, verbose_name = 'Publish messages')
    favorite = models.BooleanField(default = False, verbose_name = 'Favorite Blog')

    def createMessage(self, entry):
        """
        Creates and returns a message in this subscription for the given entry.
        If message is newly created and tags are present, they are added to the message.
        """
        if isinstance(entry, EntryBase):
            entry = entry.promote()
        filter_tags = []
        for filter in self.filter_set.all():
            passed, current_filter_tags = filter.processEntry(entry)
            if not passed:
                return
            filter_tags.extend(current_filter_tags)
        message, created = Message.objects.get_or_create(user = self.user, entry = entry)
        message.subscriptions.add(self)
        message.save()
        if created:
            message.published = self.published
            for tag in self.tags.all():
                message.tags.add(tag)
            for tag in filter_tags:
                message.tags.add(tag)
            if self.import_tags and hasattr(entry, 'tags'):
                for tag in entry.tags.all():
                    message.tags.add(tag)
            message.save()
        return message

    def applyFilter(self, filter_object):
        for message in self.message_set.all():
            passed, tags = filter_object.processEntry(message.entry.promote())
            if not passed:
                message.subscriptions.remove(self)
            else:
                for tag in tags:
                    message.tags.add(tag)
            message.save()

class Message(models.Model):
    """
    An entry related to particular user. Stores metadata added by that user
    """
    subscriptions = models.ManyToManyField(Subscription) # Null stands for user-added values
    user = models.ForeignKey(User, related_name = 'messages') # Some don't come from subscriptions. If this one does, the value should be the same as in Subcsription. related_name necessary to avoid conflict with auth.models.Message
    entry = models.ForeignKey(EntryBase)
    tags = models.ManyToManyField(Tag, verbose_name = 'Tags')
    created = models.DateTimeField(auto_now_add = True, editable = False)
    published = models.BooleanField(default = False)
    ignored = models.BooleanField(default = False) # Ignored messages are not shown
    read = models.BooleanField(default = False) # User has read the message.

    def save(self, force_insert = False, force_update = False):
        super(Message, self).save(force_insert, force_update)
        user_channels = UserChannel.objects.filter(author = self.user)
        for channel in user_channels:
            channel_tags = set(map(lambda x: x.name, channel.tags.all()))
            message_tags = set(map(lambda x: x.name, self.tags.all()))
            if len(channel_tags) == 0 or len(channel_tags.intersection(message_tags)) > 0:
                channel.updateSubscriptions(self)
                break

    @models.permalink
    def get_absolute_url(self):
        return ('stream.views.editMessage', (), { 'message_id' : self.id })

class Word(models.Model):
    name = models.CharField(max_length = 25, primary_key = True)

    def save(self, force_insert = False, force_update = False):
        self.name = morphology.getWordBase(self.name)
        super(Word, self).save(force_insert, force_update)

class Filter(models.Model):
    """
    A filter that applies to all messages passing through a subscription. It may block the message, pass it as is or alter its description.
    """
    FILTER_TYPES = (
        (u'include', u'include'), # Pass only the entries containing given words
        (u'exclude', u'exclude'), # Pass only the entries not containing given words
        (u'pass', u'pass') # Do not remove anything. May just add tags.
    )

    CONDITION_TYPES = (
        (u'all', u'all'), # Search for all words in message
        (u'any', u'any') # Search for any word in message
    )

    tags = models.ManyToManyField(Tag, verbose_name = 'Tags') # Tags to apply to the message
    subscription = models.ForeignKey(Subscription)
    filter_type = models.CharField(max_length = 10, choices = FILTER_TYPES)
    condition_type = models.CharField(max_length = 10, choices = CONDITION_TYPES)
    words = models.ManyToManyField(Word)

    def processEntry(self, entry):
        """
        Returns a tuple (<should the message be passed>, <tags to append to the message>)
        """
        if self.filter_type != 'pass':
            bases = entry.getBases()
            words = map(lambda x: x.name, self.words.all())
            encounters = []
            for word in words:
                match = False
                for base in bases:
                    if word == base:
                        match = True
                        break
                encounters.append(match)
            if self.condition_type == 'any':
                condition_met = any(encounters)
            elif self.condition_type == 'all':
                condition_met = all(encounters)
            if self.filter_type == 'include':
                passed = condition_met
            elif self.filter_type == 'exclude':
                passed = not condition_met
        else:
            passed = True
        return (passed, self.tags.all())
